#!/usr/bin/env python
#
# Sidewinder Backup System
#
# $Id: backup 8 2008-06-26 22:22:02Z trevor.caira $

import atexit
import os
import subprocess
import sys
import syslog

class SubprocessError(Exception): pass

class Backup(object):
    dirs = {'target': '/backup',
            'exclude': '/etc/backup',
            'last': '/var/lib/backup/last',
            'failed': '/var/lib/backup/failed',
            'run': '/var/run/backup'}

    def __init__(self, host='localhost', root='/', target=None,
                 try_mount=False, keep_mounted=False, verbose=True,
                 sparse=False, mountpoint=None, dry_run=False):
        self.host = host
        self.root = root
        self.try_mount = try_mount
        self.keep_mounted = keep_mounted
        self.verbose = verbose
        self.sparse = sparse
        self.dry_run = dry_run
        if target is None:
            self.target = self.host_file('target')
        else:
            self.target = target
        if mountpoint is None:
            self.mountpoint = self.target
        else:
            self.mountpoint = mountpoint
        self.check()

    def check(self):
        if not os.path.isdir(self.dirs['run']):
            os.mkdir(self.dirs['run'])
        if not self.host:
            log('error: provided host name is empty')
            raise SystemExit(1)
        if os.path.isfile(self.host_file('run')):
            log('not starting backup for %s: already in progress.' %
                self.host)
            raise SystemExit(2)

    def host_file(self, dir_):
        return os.path.join(self.dirs[dir_], self.host)

    def lock(self, pid):
        f = file(self.host_file('run'), 'w')
        try:
            f.write('%s\n' % pid)
        finally:
            f.close()

    def mount(self):
        if self.try_mount:
            if subprocess.call(['mount', self.mountpoint]):
                raise SubprocessError
            atexit.register(self.unmount)

    def run(self):
        self.mount()
        log('starting system backup for %s.' % self.host)
        if self.verbose:
            verbose = ('--verbose', '--progress')
        else:
            verbose = ()
        if self.sparse:
            sparse = ('--sparse',)
        else:
            sparse = ()
        if self.dry_run:
            dry_run = ('--dry-run',)
        else:
            dry_run = ()
        args = ('nice', 'rsync', '--archive', '--update', '--delete',
                '--delete-excluded') + verbose + sparse + dry_run + \
               ('--exclude-from', self.host_file('exclude'), self.root,
                self.target)
        pid = subprocess.Popen(args).pid
        try:
            self.lock(pid)
        except IOError:
            import signal
            os.kill(pid, signal.SIGTERM)
            raise
        try:
            rv = os.waitpid(pid, 0)[1]
        finally:
            os.remove(self.host_file('run'))
        if rv != 0:
            if subprocess.call(['touch', self.host_file('failed')]):
                raise SubprocessError
            log('backup for %s failed.' % self.host)
            raise SystemExit(3)
        if os.path.isfile(self.host_file('failed')):
            os.remove(self.host_file('failed'))
        file(self.host_file('last'), 'w').close()
        log('backup for %s successful.' % self.host)

    def unmount(self):
        if not self.keep_mounted:
            import time
            subprocess.call('sync')
            time.sleep(1)
            if subprocess.call(['umount', self.mountpoint]):
                raise SubprocessError

def log(message):
    syslog.syslog(message)
    sys.stderr.write('%s\n' % message)

def get_options():
    from optparse import OptionParser

    parser = OptionParser(usage='%prog [OPTIONS]',
                          version='%prog $Rev: 8 $')
    parser.add_option('-H', '--host', type='string', dest='host',
                      help='Pull backup from this host. Defaults to \
localhost.')
    parser.add_option('-c', '--cron', action='store_true', dest='cron',
                      help='Print only errors.')
    parser.add_option('-S', '--sparse', action='store_true', dest='sparse',
                      help='Handle sparse files efficiently.')
    parser.add_option('-m', '--mount', action='store_true', dest='mount',
                      help='Try to mount the backup mountpoint before \
starting the backup and unmount it after.')
    parser.add_option('-k', '--keep-mounted', action='store_true',
                      dest='keep', help="Don't unmount the target directory \
after backing up. Doesn't apply if -m is not enabled.")
    parser.add_option('-t', '--target', type='string', dest='target',
                      help='Override default destination directory of \
/backup/HOST.')
    parser.add_option('-p', '--mountpoint', type='string',
                      dest='mountpoint', help='Mount this directory \
before backing up. Implies -m. The default is to back up to the target \
directory.')
    parser.add_option('-n', '--dry-run', action='store_true',
                      dest='dry_run', help='Do not actually transfer or \
delete any files.')
    return parser.parse_args()[0]

def parse_args():
    cmdline = get_options()
    options = {'verbose': not cmdline.cron, 'try_mount': cmdline.mount,
               'keep_mounted': cmdline.keep, 'sparse': cmdline.sparse,
                'dry_run': cmdline.dry_run}
    if cmdline.host:
        if cmdline.host != 'localhost':
            options['root'] = '%s:/' % cmdline.host
            options['host'] = cmdline.host
    if cmdline.target:
        options['target'] = cmdline.target
    if cmdline.mountpoint:
        options['try_mount'] = True
        options['mountpoint'] = cmdline.mountpoint
    return options

def main():
    syslog.openlog('sidewinder')
    atexit.register(syslog.closelog)
    options = parse_args()
    try:
        Backup(**options).run()
    except KeyboardInterrupt:
        log('interrupted')

if __name__ == '__main__':
    main()
